We start by loading a couple of packages for data manipulation, dimension reduction and fancy representations.
library(tidyverse) # advanced data manipulation and vizualisation
library(knitr) # R notebook export and formatting
library(FactoMineR) # Factor analysis
library(factoextra) # Fancy plotting of FactoMineR outputs
library(corrplot) # Fancy plotting of matrices
library(GGally) # Easy-to-use ggplot2 extensions
library(ggpubr)
library(maps)
library(ggrepel)
theme_set(theme_bw()) # set default ggplot2 theme to black and white
There are different types of MDS algorithms, including
Classical multidimensional scaling Preserves the original distance metric, between points, as well as possible. That is the fitted distances on the MDS map and the original distances are in the same metric. Classic MDS belongs to the so-called metric multidimensional scaling category.
It is also known as principal coordinates analysis. It is suitable for quantitative data.
Non-metric multidimensional scaling It is also known as ordinal MDS. Here, it is not the metric of a distance value that is important or meaningful, but its value in relation to the distances between other pairs of objects.
Ordinal MDS constructs fitted distances that are in the same rank order as the original distance. For example, if the distance of apart objects \(1\) and \(5\) rank fifth in the original distance data, then they should also rank fifth in the MDS configuration.
It is suitable for qualitative data.
To perform MDS, we may either use:
cmdscale() [stats package]: Compute classical (metric) multidimensional scaling.
?cmdscale()
isoMDS() [MASS package]: Compute Kruskal's non-metric multidimensional scaling (one form of non-metric MDS).
?isoMDS()
sammon() [MASS package]: Compute Sammon's non-linear mapping (one form of non-metric MDS).
?sammon()
All of these functions take a distance object as main argument and a number \(k\) corresponding to the desired number of dimensions in the scaled output.
We consider the swiss data that contains fertility and socio-economic data on 47 French speaking provinces in Switzerland.
data("swiss")
swiss %>% head(15)
## Fertility Agriculture Examination Education Catholic
## Courtelary 80.2 17.0 15 12 9.96
## Delemont 83.1 45.1 6 9 84.84
## Franches-Mnt 92.5 39.7 5 5 93.40
## Moutier 85.8 36.5 12 7 33.77
## Neuveville 76.9 43.5 17 15 5.16
## Porrentruy 76.1 35.3 9 7 90.57
## Broye 83.8 70.2 16 7 92.85
## Glane 92.4 67.8 14 8 97.16
## Gruyere 82.4 53.3 12 7 97.67
## Sarine 82.9 45.2 16 13 91.38
## Veveyse 87.1 64.5 14 6 98.61
## Aigle 64.1 62.0 21 12 8.52
## Aubonne 66.9 67.5 14 7 2.27
## Avenches 68.9 60.7 19 12 4.43
## Cossonay 61.7 69.3 22 5 2.82
## Infant.Mortality
## Courtelary 22.2
## Delemont 22.2
## Franches-Mnt 20.2
## Moutier 20.3
## Neuveville 20.6
## Porrentruy 26.6
## Broye 23.6
## Glane 24.9
## Gruyere 21.0
## Sarine 24.4
## Veveyse 24.5
## Aigle 16.5
## Aubonne 19.1
## Avenches 22.7
## Cossonay 18.7
rownames(swiss)
## [1] "Courtelary" "Delemont" "Franches-Mnt" "Moutier" "Neuveville"
## [6] "Porrentruy" "Broye" "Glane" "Gruyere" "Sarine"
## [11] "Veveyse" "Aigle" "Aubonne" "Avenches" "Cossonay"
## [16] "Echallens" "Grandson" "Lausanne" "La Vallee" "Lavaux"
## [21] "Morges" "Moudon" "Nyone" "Orbe" "Oron"
## [26] "Payerne" "Paysd'enhaut" "Rolle" "Vevey" "Yverdon"
## [31] "Conthey" "Entremont" "Herens" "Martigwy" "Monthey"
## [36] "St Maurice" "Sierre" "Sion" "Boudry" "La Chauxdfnd"
## [41] "Le Locle" "Neuchatel" "Val de Ruz" "ValdeTravers" "V. De Geneve"
## [46] "Rive Droite" "Rive Gauche"
# Cmpute MDS
mds <- swiss %>%
dist(method='euclidean') %>%
cmdscale() %>%
as_tibble()
## Warning: The `x` argument of `as_tibble.matrix()` must have unique column names if `.name_repair` is omitted as of tibble 2.0.0.
## Using compatibility `.name_repair`.
## This warning is displayed once every 8 hours.
## Call `lifecycle::last_warnings()` to see where this warning was generated.
colnames(mds) <- c("Dim.1", "Dim.2")
mds %>% head(10)
## # A tibble: 10 x 2
## Dim.1 Dim.2
## <dbl> <dbl>
## 1 37.0 -17.4
## 2 -42.8 -14.7
## 3 -51.1 -19.3
## 4 7.72 -5.46
## 5 35.0 5.13
## 6 -44.2 -25.9
## 7 -56.4 3.23
## 8 -61.3 1.00
## 9 -56.4 -12.3
## 10 -47.5 -19.9
# Plot MDS
ggscatter(mds, x = "Dim.1", y = "Dim.2",
label = rownames(swiss),
size = 1,
repel = TRUE)
Create \(3\) groups using \(k\)-means clustering and color points by group.
# K-means clustering
clust <- kmeans(mds, 3)$cluster %>%
as.factor()
mds <- mds %>%
mutate(groups = clust)
# Plot and color by groups
ggscatter(mds, x = "Dim.1", y = "Dim.2",
label = rownames(swiss),
color = "groups",
palette = "jco",
size = 1,
ellipse = TRUE,
ellipse.type = "convex",
repel = TRUE)
Kruskal's non-metric multidimensional scaling
# Cmpute MDS
library(MASS)
##
## Attaching package: 'MASS'
## The following object is masked from 'package:dplyr':
##
## select
mds <- swiss %>%
dist('euclidean') %>%
isoMDS() %>%
.$points %>%
as_tibble()
## initial value 5.463800
## iter 5 value 4.499103
## iter 5 value 4.495335
## iter 5 value 4.492669
## final value 4.492669
## converged
colnames(mds) <- c("Dim.1", "Dim.2")
# Plot MDS
ggscatter(mds, x = "Dim.1", y = "Dim.2",
label = rownames(swiss),
size = 1,
repel = TRUE)
Sammon's non-linear mapping:
# Cmpute MDS
library(MASS)
mds <- swiss %>%
dist() %>%
sammon() %>%
.$points %>%
as_tibble()
## Initial stress : 0.01959
## stress after 0 iters: 0.01959
colnames(mds) <- c("Dim.1", "Dim.2")
# Plot MDS
ggscatter(mds, x = "Dim.1", y = "Dim.2",
label = rownames(swiss),
size = 1,
repel = TRUE)
MDS can also be used to detect a hidder pattern in a correlation matrix, but it is easy to transform it to a measure of dissimilarity. Distance between objects can be calculated as 1 - res.cor.
Perform a classical MDS on the mtcars dataset and comment
res.cor <- cor(mtcars, method = "spearman")
mds.cor <- (1 - res.cor) %>%
cmdscale() %>%
as_tibble()
colnames(mds.cor) <- c("Dim.1", "Dim.2")
ggscatter(mds.cor, x = "Dim.1", y = "Dim.2",
size = 1,
label = colnames(res.cor),
repel = TRUE)
** Perform a PCA on the mtcars dataset. Comment the differences with MDS. What are the main differences between MDS and PCA**
FactoMineR::PCA(mtcars)
## **Results for the Principal Component Analysis (PCA)**
## The analysis was performed on 32 individuals, described by 11 variables
## *The results are available in the following objects:
##
## name description
## 1 "$eig" "eigenvalues"
## 2 "$var" "results for the variables"
## 3 "$var$coord" "coord. for the variables"
## 4 "$var$cor" "correlations variables - dimensions"
## 5 "$var$cos2" "cos2 for the variables"
## 6 "$var$contrib" "contributions of the variables"
## 7 "$ind" "results for the individuals"
## 8 "$ind$coord" "coord. for the individuals"
## 9 "$ind$cos2" "cos2 for the individuals"
## 10 "$ind$contrib" "contributions of the individuals"
## 11 "$call" "summary statistics"
## 12 "$call$centre" "mean of the variables"
## 13 "$call$ecart.type" "standard error of the variables"
## 14 "$call$row.w" "weights for the individuals"
## 15 "$call$col.w" "weights for the variables"
The UScitiesD dataset gives 'straight line' distances between \(10\) cities in the US.
?UScitiesD
cities <- UScitiesD
Just looking at the table does not provide any information about the underlying structure of the data (i.e. the position of each city on a map). We are going to apply MDS to recover the geographical structure.
df <- tibble::as_tibble(us.cities)
df <- df %>% filter(name %in% c("Atlanta GA", "Chicago IL", "Denver CO", "Houston TX",
"Los Angeles CA", "Miami FL", "New York NY", "San Francisco CA", "Seattle WA", "WASHINGTON DC"))
usa <- map_data("usa")
gg1 <- ggplot() +
geom_polygon(data = usa, aes(x=long, y = lat, group = group), fill = "NA", color = "blue") +
coord_fixed(1.3)
labs <- data.frame(
long = df$long,
lat = df$lat,
names = df$name,
stringsAsFactors = FALSE
)
gg1 +
geom_point(data = labs, aes(x = long, y = lat), color = "black", size = 5) +
geom_point(data = labs, aes(x = long, y = lat), color = "yellow", size = 4) +
geom_text_repel(data = labs, aes(x=long , y=lat, label = names), size=3)
cities_mds <- cities %>%
cmdscale() %>%
scale() %>%
as_tibble()
colnames(cities_mds) <- c("Dim.1", "Dim.2")
cities_mds
## # A tibble: 10 x 2
## Dim.1 Dim.2
## <dbl> <dbl>
## 1 -0.697 0.330
## 2 -0.370 -0.787
## 3 0.467 -0.0584
## 4 -0.156 1.32
## 5 1.17 0.901
## 6 -1.10 1.34
## 7 -1.04 -1.20
## 8 1.38 0.260
## 9 1.30 -1.34
## 10 -0.949 -0.775
us.cities.name <- c("Atlanta", "Chicago", "Denver", "Houston",
"LA", "Miami", "NYC", "SF", "Seattle", "DC")
cities_mds_rev <- cities_mds
cities_mds_rev$Dim.1 <- -cities_mds$Dim.1
cities_mds_rev$Dim.2 <- -cities_mds$Dim.2
ggscatter(cities_mds, x = "Dim.1", y = "Dim.2",
label = us.cities.name,
size = 1,
repel = TRUE)
ggscatter(cities_mds_rev, x = "Dim.1", y = "Dim.2",
label = us.cities.name,
size = 1,
repel = TRUE)
** Plot the MDS representation of the pair-wise distances for the 10 US cities. Comment on the results. Is this the usual US map? **
p <- ggplot(data = cities_mds) + geom_point(mapping = aes(x=Dim.1 , y=Dim.2), color="yellow", size=8)
p <- p + geom_text(data = cities_mds, mapping = aes(x=Dim.1 , y=Dim.2), label = us.cities.name, size=3)
p <- p + labs(title = "MDS representation of pair-wise distances of 21 European cities")
p
The cities on the map are not in their expected locations. It seems the map is not only mirrored, but flipped (Australia's point of view in some way). Indeed, this is the proper time to point to this fact that MDS only tries to preserve the inter-object distances,and therefore there is nothing wrong with the map. By operations such as rotation of the map, the distances remain intact. The map must be rotated 180 degrees. It is possible to do so by multiplying the coordinates of each point into -1.
** Solve the issue above by rotating the figure the proper way**
p <- ggplot(data = cities_mds) + geom_point(mapping = aes(x=-Dim.1 , y=-Dim.2), color="yellow", size=8)
p <- p + geom_text(data = cities_mds, mapping = aes(x=-Dim.1 , y=-Dim.2), label = us.cities.name, size=3)
p <- p + labs(title = "MDS representation of pair-wise distances of 21 European cities")
p
Recall that the principal components variables \(Z\) of a data matrix \(X\) can be computed from the inner-product (gram) matrix \(K=XX^\top\). In detail, we compute the eigen-decomposition of the double-centered version of the gram matrix \[ \tilde{K} = (I-M) K (I-M) = U D^2 U^\top, \] where \(M = \frac 1n \mathbf{1}\mathbf{1}^\top\) and \(Z = UD\). Kernel PCA mimics this proceduren interpreting the kernel matrix \(\mathbf K = (K(x_i,x_{i^{'}}))_{1 \leq i,i^{'} \leq n}\) as an inner-product matrix of the implicit features \(\langle \phi(x_i), \phi(x_{i^{'}}) \rangle\) and finding its eigen vectors.
library(reticulate)
use_python("/usr/local/bin/python")
reticulate::py_config()
## python: /Users/florianbourgey/Library/r-miniconda/envs/r-reticulate/bin/python
## libpython: /Users/florianbourgey/Library/r-miniconda/envs/r-reticulate/lib/libpython3.6m.dylib
## pythonhome: /Users/florianbourgey/Library/r-miniconda/envs/r-reticulate:/Users/florianbourgey/Library/r-miniconda/envs/r-reticulate
## version: 3.6.10 |Anaconda, Inc.| (default, Mar 25 2020, 18:53:43) [GCC 4.2.1 Compatible Clang 4.0.1 (tags/RELEASE_401/final)]
## numpy: /Users/florianbourgey/Library/r-miniconda/envs/r-reticulate/lib/python3.6/site-packages/numpy
## numpy_version: 1.19.2
py_install("sklearn", pip=TRUE) # install package sklearn
py_install("matplotlib", pip=TRUE) # install matplotlib
Let us start with a simple example of 2 half-moon shapes generated by the make_moons functions from sklearn_learn.
import matplotlib.pyplot as plt
from sklearn.datasets import make_moons
X, y = make_moons(n_samples=100, random_state=123)
plt.figure(figsize=(8,6))
plt.scatter(X[y==0, 0], X[y==0, 1], color='red', alpha=0.5)
plt.scatter(X[y==1, 0], X[y==1, 1], color='blue', alpha=0.5)
plt.title('A nonlinear 2Ddataset')
plt.ylabel('y coordinate')
plt.xlabel('x coordinate')
plt.show()
Are the two half-moon shapes linearly separable? Do you expect PCA to give satisfactory results? Perform a PCA using sklearn with n_components= \(1\) and \(2\). Comment.
from sklearn.decomposition import PCA
import matplotlib.pyplot as plt
from sklearn.datasets import make_moons
import numpy as np
X, y = make_moons(n_samples=100, random_state=123)
scikit_pca = PCA(n_components=1)
X_spca = scikit_pca.fit_transform(X)
plt.figure(figsize=(8,6))
plt.scatter(X_spca[y==0, 0], np.zeros((50,1)), color='red', alpha=0.5)
plt.scatter(X_spca[y==1, 0], np.zeros((50,1)), color='blue', alpha=0.5)
plt.title('First principal component after Linear PCA')
plt.xlabel('PC1')
plt.show()
Since the two half-moon shapes are linearly inseparable, we expect that the 'classic' PCA will fail to give us a 'good' representation of the data in 1D space. As we can see, the resulting principal components do not yield a subspace where the data is linearly separated well.
Perform a KernelPCA using the KernelPCA function from sklearn with the rbf kernel and \(\gamma=15\) with both n_components=\(1\) and \(2\).Comment. Try different values for \(\gamma\) and different kernels. Comment.
from sklearn.decomposition import KernelPCA
import matplotlib.pyplot as plt
from sklearn.datasets import make_moons
import numpy as np
gamma = 15
X, y = make_moons(n_samples=100, random_state=123)
X_pc = KernelPCA(gamma=15, n_components=1, kernel='rbf').fit_transform(X)
plt.figure(figsize=(8,6))
plt.scatter(X_pc[y==0, 0], np.zeros((50,1)), color='red', alpha=0.5)
plt.scatter(X_pc[y==1, 0], np.zeros((50,1)), color='blue', alpha=0.5)
plt.title('First principal components after RBF Kernel PCA with $\gamma$={}'.format(gamma))
plt.xlabel('PC1')
plt.show()
from sklearn.decomposition import KernelPCA
import matplotlib.pyplot as plt
from sklearn.datasets import make_moons
import numpy as np
gamma = 15
X, y = make_moons(n_samples=100, random_state=123)
X_pc = KernelPCA(gamma=15, n_components=2, kernel='rbf').fit_transform(X)
plt.figure(figsize=(8,6))
plt.scatter(X_pc[y==0, 0], X_pc[y==0, 1], color='red', alpha=0.5)
plt.scatter(X_pc[y==1, 0], X_pc[y==1, 1], color='blue', alpha=0.5)
plt.title('First 2 principal components after RBF Kernel PCA with $\gamma$={}'.format(gamma))
plt.xlabel('PC1')
plt.ylabel('PC2')
plt.show()
Another well-known example for which linear PCA will fail is the classic case of \(2\) concentric circles with random noise produced by scikit-learn???s make_circles.
from sklearn.datasets import make_circles
import matplotlib.pyplot as plt
import numpy as np
X, y = make_circles(n_samples=1000, random_state=123, noise=0.1, factor=0.2)
plt.figure(figsize=(8,6))
plt.scatter(X[y==0, 0], X[y==0, 1], color='red', alpha=0.5)
plt.scatter(X[y==1, 0], X[y==1, 1], color='blue', alpha=0.5)
plt.title('Concentric circles')
plt.ylabel('y coordinate')
plt.xlabel('x coordinate')
plt.show()
Perform both a linear and kernel PCA on the make_circles dataset
Unrolling the Swiss roll is a much more challenging task (see Swiss roll).
from sklearn.datasets import make_swiss_roll
from mpl_toolkits.mplot3d import Axes3D
import matplotlib.pyplot as plt
X, color = make_swiss_roll(n_samples=800, random_state=123)
fig = plt.figure(figsize=(7,7))
ax = fig.add_subplot(111, projection='3d')
ax.scatter(X[:, 0], X[:, 1], X[:, 2], c=color, cmap=plt.cm.rainbow)
plt.title('Swiss Roll in 3D')
plt.show()
Again, try to perform both linear and kernel PCA on the Swiss roll dataset. Try with different values of \(\gamma\). Are you satisfied with the results?
from sklearn.datasets import make_swiss_roll
from mpl_toolkits.mplot3d import Axes3D
import matplotlib.pyplot as plt
from sklearn.decomposition import KernelPCA
gamma = 0.05
X, color = make_swiss_roll(n_samples=800, random_state=123)
X_pc = KernelPCA(gamma=gamma, n_components=2, kernel='rbf').fit_transform(X)
plt.figure(figsize=(8,6))
plt.scatter(X_pc[:, 0], X_pc[:, 1], c=color, cmap=plt.cm.rainbow)
plt.title('First 2 principal components after RBF Kernel PCA with $\gamma$={}'.format(gamma))
plt.xlabel('PC1')
plt.ylabel('PC2')
plt.show()
In 2000, Sam T. Roweis and Lawrence K. Saul Nonlinear dimensionality reduction by locally linear embedding introduced an unsupervised learning algorithm called locally linear embedding (LLE) that is better suited to identify patterns in the high-dimensional feature space and solves our problem of nonlinear dimensionality reduction for the Swiss roll.
Locally linear embedding (LLE) seeks a lower-dimensional projection of the data which preserves distances within local neighborhoods. It can be thought of as a series of local Principal Component Analyses which are globally compared to find the best non-linear embedding.
Using the locally_linear_embedding class, 'unroll' the Swiss roll both in \(1\) and \(2\) dimensions.
import numpy as np
from sklearn.datasets import make_swiss_roll
from mpl_toolkits.mplot3d import Axes3D
import matplotlib.pyplot as plt
from sklearn.manifold import locally_linear_embedding
X, color = make_swiss_roll(n_samples=800, random_state=123)
X_lle, err = locally_linear_embedding(X, n_neighbors=12, n_components=1)
plt.figure(figsize=(8,6))
plt.scatter(X_lle, np.zeros((800,1)), c=color, cmap=plt.cm.rainbow)
plt.title('First principal component after Locally Linear Embedding')
plt.show()